{-# LANGUAGE ViewPatterns #-}

-- | Validate input queries against remote schemas.
module Hasura.RQL.DDL.RemoteRelationship.Validate
  ( validateToSchemaRelationship,
    errorToText,
  )
where

import Data.HashMap.Strict qualified as HM
import Data.HashSet qualified as HS
import Data.List.NonEmpty qualified as NE
import Data.Text.Extended
import Hasura.Prelude hiding (first)
import Hasura.RQL.Types.Common
import Hasura.RQL.Types.Relationships.Remote
import Hasura.RQL.Types.Relationships.ToSchema
import Hasura.RQL.Types.RemoteSchema
import Hasura.RQL.Types.SchemaCache
import Hasura.Server.Utils (englishList)
import Language.GraphQL.Draft.Syntax qualified as G

-- | An error validating the remote relationship.
data ValidationError
  = RemoteSchemaNotFound !RemoteSchemaName
  | CouldntFindRemoteField !G.Name !G.Name
  | FieldNotFoundInRemoteSchema !G.Name
  | NoSuchArgumentForRemote !G.Name
  | MissingRequiredArgument !G.Name
  | TypeNotFound !G.Name
  | JoinFieldNonExistent !LHSIdentifier !FieldName !(HS.HashSet FieldName)
  | ExpectedTypeButGot !G.GType !G.GType
  | InvalidType !G.GType !Text
  | InvalidVariable !G.Name !(HS.HashSet G.Name)
  | NullNotAllowedHere
  | InvalidGTypeForStripping !G.GType
  | UnsupportedMultipleElementLists
  | UnsupportedEnum
  | InvalidGraphQLName !Text
  | IDTypeJoin !G.Name
  | -- | TODO: Can this be made not reachable?
    -- This is the case where the type of the columns that are mapped do not
    -- have a graphql representation. This case is probably not reachable as
    -- having a db type which can't be representable in GraphQL should definitely
    -- fail the entire schema generation process
    CannotGenerateGraphQLTypeName !G.Name
  deriving (Show, Eq)

errorToText :: ValidationError -> Text
errorToText = \case
  RemoteSchemaNotFound name ->
    "remote schema with name " <> name <<> " not found"
  CouldntFindRemoteField name ty ->
    "remote field with name " <> name <<> " and type " <> ty <<> " not found"
  FieldNotFoundInRemoteSchema name ->
    "field with name " <> name <<> " not found in remote schema"
  NoSuchArgumentForRemote name ->
    "argument with name " <> name <<> " not found in remote schema"
  MissingRequiredArgument name ->
    "required argument with name " <> name <<> " is missing"
  TypeNotFound ty ->
    "type with name " <> ty <<> " not found"
  JoinFieldNonExistent (LHSIdentifier lhs) fieldName allowedJoinFields ->
    let helpText =
          case NE.nonEmpty $ map dquote $ toList allowedJoinFields of
            Nothing -> ""
            Just allowedFields -> ", the allowed fields are " <> englishList "or" allowedFields
     in "field with name " <> fieldName <<> "is not provided by the lhs entity" <>> lhs
          <<> "for defining a join condition" <> helpText
  ExpectedTypeButGot expTy actualTy ->
    "expected type " <> G.getBaseType expTy <<> " but got " <>> G.getBaseType actualTy
  InvalidType ty err ->
    "type " <> G.getBaseType ty <<> err
  InvalidVariable var _ ->
    "variable " <> var <<> " is not found"
  NullNotAllowedHere ->
    "null is not allowed here"
  InvalidGTypeForStripping ty ->
    "type " <> G.getBaseType ty <<> " is invalid for stripping"
  UnsupportedMultipleElementLists ->
    "multiple elements in list value is not supported"
  UnsupportedEnum ->
    "enum value is not supported"
  InvalidGraphQLName t ->
    t <<> " is not a valid GraphQL identifier"
  IDTypeJoin typeName ->
    "Only ID, Int, uuid or String scalar types can be joined to the ID type, but received " <>> typeName
  CannotGenerateGraphQLTypeName typeName ->
    "the name of the scalar type " <> toTxt typeName
      <> " is not a valid GraphQL identifier, "
      <> " so columns of such type cannot be used in a remote schema mapping "

-- | Validate a remote schema relationship given a context.
validateToSchemaRelationship ::
  (MonadError ValidationError m) =>
  ToSchemaRelationshipDef ->
  LHSIdentifier ->
  RelName ->
  (RemoteSchemaInfo, IntrospectionResult) ->
  HM.HashMap FieldName joinField ->
  m (HM.HashMap FieldName joinField, RemoteSchemaFieldInfo)
validateToSchemaRelationship schema lhsIdentifier name (remoteSchemaInfo, introspectionResult) lhsJoinFields = do
  let remoteSchemaName = _trrdRemoteSchema schema
  requiredLHSJoinFields <- forM (toList $ _trrdLhsFields schema) $ \fieldName -> do
    fmap (fieldName,) $
      onNothing (HM.lookup fieldName lhsJoinFields) $
        throwError $ JoinFieldNonExistent lhsIdentifier fieldName $ HM.keysSet lhsJoinFields
  hasuraFieldsVariablesMap <-
    fmap HM.fromList $ for requiredLHSJoinFields $ \(fieldName, field) -> (,field) <$> hasuraFieldToVariable fieldName
  let schemaDoc = irDoc introspectionResult
      queryRootName = irQueryRoot introspectionResult
  queryRoot <-
    onNothing (lookupObject schemaDoc queryRootName) $
      throwError $ FieldNotFoundInRemoteSchema queryRootName
  (_, (leafParamMap, leafTypeMap)) <-
    foldlM
      (buildRelationshipTypeInfo hasuraFieldsVariablesMap schemaDoc)
      (queryRoot, (mempty, mempty))
      (unRemoteFields $ _trrdRemoteField schema)
  pure $
    (HM.fromList requiredLHSJoinFields,) $
      RemoteSchemaFieldInfo
        { _rrfiName = name,
          _rrfiParamMap = leafParamMap,
          _rrfiRemoteFields = _trrdRemoteField schema,
          _rrfiRemoteSchema = remoteSchemaInfo,
          -- adding the new input types after stripping the values of the
          -- schema document
          _rrfiInputValueDefinitions = HM.elems leafTypeMap,
          _rrfiRemoteSchemaName = remoteSchemaName,
          _rrfiLHSIdentifier = lhsIdentifier
        }
  where
    getObjTyInfoFromField ::
      RemoteSchemaIntrospection ->
      G.FieldDefinition RemoteSchemaInputValueDefinition ->
      Maybe (G.ObjectTypeDefinition RemoteSchemaInputValueDefinition)
    getObjTyInfoFromField schemaDoc field =
      let baseTy = G.getBaseType (G._fldType field)
       in lookupObject schemaDoc baseTy

    isValidType schemaDoc field =
      let baseTy = G.getBaseType (G._fldType field)
       in case lookupType schemaDoc baseTy of
            Just (G.TypeDefinitionScalar _) -> True
            Just (G.TypeDefinitionInterface _) -> True
            Just (G.TypeDefinitionUnion _) -> True
            Just (G.TypeDefinitionEnum _) -> True
            _ -> False

    buildRelationshipTypeInfo ::
      (MonadError ValidationError m) =>
      HashMap G.Name joinField ->
      RemoteSchemaIntrospection ->
      ( G.ObjectTypeDefinition RemoteSchemaInputValueDefinition,
        ( HashMap G.Name RemoteSchemaInputValueDefinition,
          HashMap G.Name (G.TypeDefinition [G.Name] RemoteSchemaInputValueDefinition)
        )
      ) ->
      FieldCall ->
      m
        ( G.ObjectTypeDefinition RemoteSchemaInputValueDefinition,
          ( HashMap G.Name RemoteSchemaInputValueDefinition,
            HashMap G.Name (G.TypeDefinition [G.Name] RemoteSchemaInputValueDefinition)
          )
        )
    buildRelationshipTypeInfo hasuraFieldsVariablesMap schemaDoc (objTyInfo, (_, typeMap)) fieldCall = do
      objFldDefinition <- lookupField (fcName fieldCall) objTyInfo
      let providedArguments = getRemoteArguments $ fcArguments fieldCall
      validateRemoteArguments
        (mapFromL (G._ivdName . _rsitdDefinition) (G._fldArgumentsDefinition objFldDefinition))
        providedArguments
        hasuraFieldsVariablesMap
        schemaDoc
      let eitherParamAndTypeMap =
            runStateT
              ( stripInMap
                  name
                  lhsIdentifier
                  schemaDoc
                  (mapFromL (G._ivdName . _rsitdDefinition) (G._fldArgumentsDefinition objFldDefinition))
                  providedArguments
              )
              typeMap
      (newParamMap, newTypeMap) <- onLeft eitherParamAndTypeMap throwError
      innerObjTyInfo <-
        onNothing (getObjTyInfoFromField schemaDoc objFldDefinition) $
          bool
            ( throwError $
                InvalidType (G._fldType objFldDefinition) "only output type is expected"
            )
            (pure objTyInfo)
            (isValidType schemaDoc objFldDefinition)
      pure
        ( innerObjTyInfo,
          (newParamMap, newTypeMap)
        )

-- | Return a map with keys deleted whose template argument is
-- specified as an atomic (variable, constant), keys which are kept
-- have their values modified by 'stripObject' or 'stripList'.
-- This function creates the 'HashMap G.Name G.InputValueDefinition' which modifies
-- the original input parameters (if any) of the remote node/table being used. Only
-- list or object types are preserved and other types are stripped off. The object or
-- list types are preserved because they can be merged, if any arguments are
-- provided by the user while querying a remote join field.
stripInMap ::
  RelName ->
  LHSIdentifier ->
  RemoteSchemaIntrospection ->
  HM.HashMap G.Name RemoteSchemaInputValueDefinition ->
  HM.HashMap G.Name (G.Value G.Name) ->
  StateT
    (HashMap G.Name (G.TypeDefinition [G.Name] RemoteSchemaInputValueDefinition))
    (Either ValidationError)
    (HM.HashMap G.Name RemoteSchemaInputValueDefinition)
stripInMap relName lhsIdentifier types schemaArguments providedArguments =
  fmap
    (HM.mapMaybe id)
    ( HM.traverseWithKey
        ( \name remoteInpValDef@(RemoteSchemaInputValueDefinition inpValInfo _preset) ->
            case HM.lookup name providedArguments of
              Nothing -> pure $ Just remoteInpValDef
              Just value -> do
                maybeNewGType <- stripValue relName lhsIdentifier types (G._ivdType inpValInfo) value
                pure
                  ( fmap
                      ( \newGType ->
                          let newInpValInfo = inpValInfo {G._ivdType = newGType}
                           in RemoteSchemaInputValueDefinition newInpValInfo Nothing
                      )
                      maybeNewGType
                  )
        )
        schemaArguments
    )

-- | Strip a value type completely, or modify it, if the given value
-- is atomic-ish.
stripValue ::
  RelName ->
  LHSIdentifier ->
  RemoteSchemaIntrospection ->
  G.GType ->
  G.Value G.Name ->
  StateT
    (HashMap G.Name (G.TypeDefinition [G.Name] RemoteSchemaInputValueDefinition))
    (Either ValidationError)
    (Maybe G.GType)
stripValue name lhsIdentifier types gtype value = do
  case value of
    G.VVariable {} -> pure Nothing
    G.VInt {} -> pure Nothing
    G.VFloat {} -> pure Nothing
    G.VString {} -> pure Nothing
    G.VBoolean {} -> pure Nothing
    G.VNull {} -> pure Nothing
    G.VEnum {} -> pure Nothing
    G.VList values ->
      case values of
        [] -> pure Nothing
        [gvalue] -> stripList name lhsIdentifier types gtype gvalue
        _ -> lift (Left UnsupportedMultipleElementLists)
    G.VObject keyPairs ->
      fmap Just (stripObject name lhsIdentifier types gtype keyPairs)

-- | Produce a new type for the list, or strip it entirely.
stripList ::
  RelName ->
  LHSIdentifier ->
  RemoteSchemaIntrospection ->
  G.GType ->
  G.Value G.Name ->
  StateT
    (HashMap G.Name (G.TypeDefinition [G.Name] RemoteSchemaInputValueDefinition))
    (Either ValidationError)
    (Maybe G.GType)
stripList name lhsIdentifier types originalOuterGType value =
  case originalOuterGType of
    G.TypeList nullability innerGType -> do
      maybeNewInnerGType <- stripValue name lhsIdentifier types innerGType value
      pure (G.TypeList nullability <$> maybeNewInnerGType)
    _ -> lift (Left (InvalidGTypeForStripping originalOuterGType))

-- | Produce a new type for the given InpValInfo, modified by
-- 'stripInMap'. Objects can't be deleted entirely, just keys of an
-- object.
stripObject ::
  RelName ->
  LHSIdentifier ->
  RemoteSchemaIntrospection ->
  G.GType ->
  HashMap G.Name (G.Value G.Name) ->
  StateT
    (HashMap G.Name (G.TypeDefinition [G.Name] RemoteSchemaInputValueDefinition))
    (Either ValidationError)
    G.GType
stripObject name lhsIdentifier schemaDoc originalGtype templateArguments =
  case originalGtype of
    G.TypeNamed nullability originalNamedType ->
      case lookupType schemaDoc (G.getBaseType originalGtype) of
        Just (G.TypeDefinitionInputObject originalInpObjTyInfo) -> do
          let originalSchemaArguments =
                mapFromL (G._ivdName . _rsitdDefinition) $ G._iotdValueDefinitions originalInpObjTyInfo
              newNamedType =
                renameNamedType
                  (renameTypeForRelationship name lhsIdentifier)
                  originalNamedType
          newSchemaArguments <-
            stripInMap
              name
              lhsIdentifier
              schemaDoc
              originalSchemaArguments
              templateArguments
          let newInpObjTyInfo =
                originalInpObjTyInfo
                  { G._iotdValueDefinitions = HM.elems newSchemaArguments,
                    G._iotdName = newNamedType
                  }
              newGtype = G.TypeNamed nullability newNamedType
          modify (HM.insert newNamedType (G.TypeDefinitionInputObject newInpObjTyInfo))
          pure newGtype
        _ -> lift (Left (InvalidGTypeForStripping originalGtype))
    _ -> lift (Left (InvalidGTypeForStripping originalGtype))

-- -- | Produce a new name for a type, used when stripping the schema
-- -- types for a remote relationship.
-- TODO: Consider a separator character to avoid conflicts. (from master)
renameTypeForRelationship :: RelName -> LHSIdentifier -> Text -> Text
renameTypeForRelationship relName (LHSIdentifier lhsIdentifier) text =
  text <> "_remote_rel_" <> name
  where
    name = lhsIdentifier <> relNameToTxt relName

-- | Rename a type.
renameNamedType :: (Text -> Text) -> G.Name -> G.Name
renameNamedType rename =
  G.unsafeMkName . rename . G.unName

-- | Convert a field name to a variable name.
hasuraFieldToVariable ::
  (MonadError ValidationError m) =>
  FieldName ->
  m G.Name
hasuraFieldToVariable (FieldName fieldText) = do
  G.mkName fieldText `onNothing` throwError (InvalidGraphQLName fieldText)

-- | Lookup the field in the schema.
lookupField ::
  (MonadError ValidationError m) =>
  G.Name ->
  G.ObjectTypeDefinition RemoteSchemaInputValueDefinition ->
  m (G.FieldDefinition RemoteSchemaInputValueDefinition)
lookupField name objFldInfo = viaObject objFldInfo
  where
    viaObject =
      maybe (throwError (CouldntFindRemoteField name $ G._otdName objFldInfo)) pure
        . lookup name
        . HM.toList
        . mapFromL G._fldName
        . G._otdFieldsDefinition

-- | Validate remote input arguments against the remote schema.
validateRemoteArguments ::
  (MonadError ValidationError m) =>
  HM.HashMap G.Name RemoteSchemaInputValueDefinition ->
  HM.HashMap G.Name (G.Value G.Name) ->
  HM.HashMap G.Name joinField ->
  RemoteSchemaIntrospection ->
  m ()
validateRemoteArguments expectedArguments providedArguments permittedVariables schemaDocument = do
  traverse_ validateProvided (HM.toList providedArguments)
  where
    -- Not neccessary to validate if all required args are provided in the relationship
    -- traverse validateExpected (HM.toList expectedArguments)

    validateProvided (providedName, providedValue) =
      case HM.lookup providedName expectedArguments of
        Nothing -> throwError (NoSuchArgumentForRemote providedName)
        Just (G._ivdType . _rsitdDefinition -> expectedType) ->
          validateType permittedVariables providedValue expectedType schemaDocument

unwrapGraphQLType :: G.GType -> G.GType
unwrapGraphQLType = \case
  G.TypeList _ lt -> lt
  nt -> nt

-- | Validate a value against a type.
validateType ::
  (MonadError ValidationError m) =>
  HM.HashMap G.Name joinField ->
  G.Value G.Name ->
  G.GType ->
  RemoteSchemaIntrospection ->
  m ()
validateType permittedVariables value expectedGType schemaDocument =
  case value of
    G.VVariable variable ->
      case HM.lookup variable permittedVariables of
        Nothing -> throwError (InvalidVariable variable $ HM.keysSet permittedVariables)
        -- TODO: check whether the type of lhs join field is allowed
        Just _lhsJoinField -> pure ()
    G.VInt {} -> do
      let intScalarGType = mkGraphQLType intScalar
      isTypeCoercible intScalarGType expectedGType
    G.VFloat {} -> do
      let floatScalarGType = mkGraphQLType floatScalar
      isTypeCoercible floatScalarGType expectedGType
    G.VBoolean {} -> do
      let boolScalarGType = mkGraphQLType boolScalar
      isTypeCoercible boolScalarGType expectedGType
    G.VNull -> throwError NullNotAllowedHere
    G.VString {} -> do
      let stringScalarGType = mkGraphQLType stringScalar
      isTypeCoercible stringScalarGType expectedGType
    G.VEnum _ -> throwError UnsupportedEnum
    G.VList values -> do
      case values of
        [] -> pure ()
        [_] -> pure ()
        _ -> throwError UnsupportedMultipleElementLists
      assertListType expectedGType
      for_
        values
        ( \val ->
            validateType permittedVariables val (unwrapGraphQLType expectedGType) schemaDocument
        )
    G.VObject values ->
      for_
        (HM.toList values)
        ( \(name, val) ->
            let expectedNamedType = G.getBaseType expectedGType
             in case lookupType schemaDocument expectedNamedType of
                  Nothing -> throwError $ TypeNotFound expectedNamedType
                  Just typeInfo ->
                    case typeInfo of
                      G.TypeDefinitionInputObject inpObjTypeInfo ->
                        let objectTypeDefnsMap =
                              mapFromL (G._ivdName . _rsitdDefinition) $ G._iotdValueDefinitions inpObjTypeInfo
                         in case HM.lookup name objectTypeDefnsMap of
                              Nothing -> throwError $ NoSuchArgumentForRemote name
                              Just (G._ivdType . _rsitdDefinition -> expectedType) ->
                                validateType permittedVariables val expectedType schemaDocument
                      _ -> do
                        throwError $ InvalidType (mkGraphQLType name) "not an input object type"
        )
  where
    mkGraphQLType =
      G.TypeNamed (G.Nullability False)

isTypeCoercible ::
  (MonadError ValidationError m) =>
  G.GType ->
  G.GType ->
  m ()
isTypeCoercible actualType expectedType =
  -- The GraphQL spec says that, a singleton type can be coerced into  an array
  -- type. Which means that if the 'actualType' is a singleton type, like
  -- 'Int' we should be able to join this with a remote node, which expects an
  -- input argument of type '[Int]'
  -- http://spec.graphql.org/June2018/#sec-Type-System.List
  let (actualBaseType, actualNestingLevel) = getBaseTyWithNestedLevelsCount actualType
      (expectedBaseType, expectedNestingLevel) = getBaseTyWithNestedLevelsCount expectedType
   in if
          | expectedBaseType == $$(G.litName "ID") ->
            bool
              (throwError $ IDTypeJoin actualBaseType)
              (pure ())
              -- Check under `Input Coercion` https://spec.graphql.org/June2018/#sec-ID
              -- We can also include the `ID` type in the below list but it will be
              -- extraneous because at the time of writing this, we don't generate
              -- the `ID` type in the DB schema
              ( G.unName actualBaseType
                  `elem` ["ID", "Int", "String", "bigint", "smallint", "uuid"]
              )
          | actualBaseType /= expectedBaseType -> raiseValidationError
          -- we cannot coerce two types with different nesting levels,
          -- for example, we cannot coerce [Int] to [[Int]]
          | (actualNestingLevel == expectedNestingLevel || actualNestingLevel == 0) -> pure ()
          | otherwise -> raiseValidationError
  where
    raiseValidationError = throwError $ ExpectedTypeButGot expectedType actualType

assertListType ::
  (MonadError ValidationError m) =>
  G.GType ->
  m ()
assertListType actualType =
  unless
    (G.isListType actualType)
    (throwError $ InvalidType actualType "is not a list type")

getBaseTyWithNestedLevelsCount :: G.GType -> (G.Name, Int)
getBaseTyWithNestedLevelsCount ty = go ty 0
  where
    go :: G.GType -> Int -> (G.Name, Int)
    go gType ctr =
      case gType of
        G.TypeNamed _ n -> (n, ctr)
        G.TypeList _ gType' -> go gType' (ctr + 1)
